Skip to content

Add a codemode CLI for scripts from inside the container - #137

Open
mattzcarey wants to merge 8 commits into
mainfrom
feat/codemode-cli
Open

Add a codemode CLI for scripts from inside the container#137
mattzcarey wants to merge 8 commits into
mainfrom
feat/codemode-cli

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Code inside a Computer container can't talk to the host today. This adds a codemode binary to the image so a command can run codemode < script.js. The script runs on the Durable Object in a dynamic worker, via @cloudflare/codemode, with the connectors you configured as globals. codemode types prints their TypeScript, codemode search and codemode describe find one method at a time, and codemode pending shows what a paused run is waiting on. It needs no token: it reaches the host through the same outbound interception computerd uses, so being in the container is the permission. Approving a paused run is deliberately not on this surface; that decision stays on the host.

sequenceDiagram
  participant C as codemode (container)
  participant P as WorkspaceProxy
  participant B as CloudflareContainerBackend
  participant W as dynamic worker
  C->>P: ws://computer.internal/codemode
  P->>B: handleFetch(/codemode)
  B-->>C: 101, capnweb session (CodemodeRPC)
  C->>B: execute({ code })
  B->>W: codemode runtime, connectors as globals
  W-->>B: result, logs
  B-->>C: { status, result, logs }
Loading

Turn it on with the codemode option on CloudflareContainerBackend, forward /codemode next to /api, and export CodemodeRuntime from your Worker. The new examples/codemode does all of that with one exec route and a small notes connector: npm run dev then ./script/run sends codemode types, a search, a describe, and a script into the container and you get the types, then ["hello"].

Each layer has tests, including a workers test in examples/codemode that runs a script through the real runtime against the connector and checks the host refuses an approve call. I couldn't run it in a real container (no Docker here); the built binaries run and fail cleanly without a host. Docs 07 and 08, both READMEs, and three changesets are updated. The image grows by one more Node-sized binary.

A process inside the container can open a WebSocket to the host's
egress endpoint at /codemode and receive a capnweb session whose
bootstrap stub is CodemodeRPC. Unlike WorkspaceRPC, the host is the
server here and a short-lived command is the client. describe returns
the TypeScript declarations of the globals a script may call; execute
runs a script body and reports completed, paused, or error rather
than rejecting, so a failure reaches the caller as a printable result.
CloudflareContainerBackend takes a codemode option naming the Worker
Loader, the Durable Object state, and a factory for the connectors a
script may call. When set, a websocket upgrade on /codemode gets its
own capnweb session over a codemode runtime built from those
connectors; the script runs in a dynamic worker with them in scope.
The route needs no credential: a request reaches it only through the
outbound interception bound to this workspace, and any process in the
container is meant to be able to run scripts. The /api bearer token has
no counterpart here because every codemode session is its own.

WorkspaceProxy forwards /codemode to the Durable Object the way it
forwards /api. @cloudflare/codemode becomes an optional peer
dependency and is imported lazily, so consumers that never set the
option do not need it installed.
codemode reads a script from stdin, a file, or -e, sends it to the
host over a capnweb WebSocket at ws://computer.internal/codemode, and
prints the result, with console output on stderr. --types prints the
host's TypeScript declarations. Exit codes are 0 for a completed run,
1 when the script threw, 2 for a usage or connection error, and 3
when the run paused for approval on the host.

The SEA build now produces a binary per CLI and platform. computerd
keeps the FUSE assets and the compiled-in port; codemode carries only
its bundle. Both are staged into the computer-computerd-linux-x64
image and by the release image workflow. codemode starts its main
unconditionally, as computerd does, because the SEA imports the
bundle from a data: URL and there is no require.main to compare.
The example lists a small connector over the Durable Object's storage
in the container backend's codemode option, forwards /codemode next to
/api, exports CodemodeRuntime from the Worker entry, and copies the
codemode binary into its image. The workers test opens the route on
the Durable Object and runs a script through the real runtime and
dynamic worker against the connector.
Doc 07 covers the host-served /codemode route and the second binary;
doc 08 documents CodemodeRPC alongside the daemon-served interfaces.
@changeset-bot

changeset-bot Bot commented Sep 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f629c63

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@cloudflare/computerd Minor
@cloudflare/computer Minor
@cloudflare/computer-rpc Minor
@cloudflare/dofs Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Thanks for your interest in Cloudflare Computer.

This repository does not accept unsolicited pull requests. Please use one of the accepted contribution paths instead:

If a maintainer asked you to open this pull request, they can add the allow-pr label and reopen it.

@github-actions github-actions Bot closed this Sep 10, 2026
@mattzcarey mattzcarey added the allow-pr Allow a PR to remain open. label Sep 10, 2026
@mattzcarey mattzcarey reopened this Sep 10, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

@pkg-pr-new

pkg-pr-new Bot commented Sep 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@cloudflare/computer@137

commit: f629c63

The publish script copied only computerd into the image context, so
the Dockerfile's COPY of bin/codemode had no source and every release
image build would have failed. It now stages both binaries, matching
build-docker.mjs and the release-candidate image workflow.
…to codemode

codemode gains subcommands: types prints every declaration, search and
describe reach the runtime's own discovery helpers so a script author
can find one method without reading everything, and pending lists what
a paused run is waiting on. CodemodeRPC carries the same five methods.
Approving or rejecting a paused run is deliberately not on the surface:
a run pauses because a connector asked for a human's decision, and the
process that wrote the script is not that human.

Two review findings are folded in. The proxy only ever forwarded the
literal /codemode, so a configured path could not be reached; the path
option is gone and one shared constant serves both sides. The CLI sets
process.exitCode instead of calling process.exit, so a large result
piped to another process drains before it ends.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 3 new potential issues.

4 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

import { newWebSocketRpcSession } from "capnweb";
import { WebSocket } from "ws";

const DEFAULT_URL = "ws://computer.internal/codemode";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Custom egress hosts strand codemode

With a custom egressHost, codemode still dials DEFAULT_URL at computer.internal. Only the custom hostname is intercepted, so every command fails unless callers also inject CODEMODE_URL.

Learn more

The backend supports replacing its intercepted hostname through egressHost. The CLI independently defaults to computer.internal, and the backend does not add a matching CODEMODE_URL to the container environment. Therefore the CLI sends its WebSocket request to a hostname with no corresponding interception whenever the option changes.

Example: Configure egressHost: "sandbox-a.internal" to avoid a collision. The backend intercepts sandbox-a.internal, but codemode types opens ws://computer.internal/codemode and fails to connect.

Recommended fix: When codemode is configured, derive and inject CODEMODE_URL from #options.egressHost unless containerEnv.CODEMODE_URL already overrides it. Ensure the launch record includes that derived environment value so an already-running container is relaunched when the hostname changes.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +226 to +234
function openSocket(url: string): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
ws.once("open", () => resolve(ws));
ws.once("unexpected-response", (_request, response) => {
reject(new Error(`${url} answered HTTP ${response.statusCode} instead of upgrading`));
});
ws.once("error", (error) => reject(new Error(`could not connect to ${url}: ${error.message}`)));
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Connection timeout leaves socket alive

withTimeout rejects while openSocket leaves its pending WebSocket open. A blackholed endpoint keeps the event loop alive, so the CLI hangs beyond the requested timeout.

Learn more

The timeout races the connection promise but cannot cancel the work behind it. If no open, error, or unexpected-response event arrives, the WebSocket remains active after the race rejects. Node then keeps waiting on that handle even though main has set exit code 2.

Example: Set CODEMODE_URL to an address whose firewall silently drops packets and run codemode types --timeout 2000. The CLI prints the timeout after two seconds but remains alive while the WebSocket connection attempt continues.

Recommended fix: Make connection timeout cleanup own the socket. Pass an abort signal or timeout into openSocket, close or terminate the socket on expiry, and remove its listeners when the promise settles. Add a test using a server or socket that accepts TCP without completing the WebSocket handshake.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +279 to +283
const text =
typeof outcome.result === "string"
? outcome.result
: JSON.stringify(outcome.result, null, 2);
io.stdout(`${text}\n`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 BigInt results become connection failures

When a completed script returns bigint, JSON.stringify throws while report formats the successful result. The CLI exits 2 without printing it, despite capnweb supporting bigint values.

Learn more

Capnweb carries bigint by value, and the codemode runtime stores bigint results durably. The result reaches the CLI successfully, but JavaScript's standard JSON.stringify rejects bigint values. Both the regular structured-result branch and the earlier --json branch use this serializer.

Example: Run codemode -e 'return 1n'. The host records a completed execution, but the client prints Do not know how to serialize a BigInt and exits 2 instead of printing the value and exiting 0.

Recommended fix: Define a stable CLI JSON representation for bigint and use one shared serializer in print and report. Add coverage for top-level and nested bigint results in normal and --json output.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

A Worker + Durable Object with one exec route and a container that
carries codemode, plus a small notes connector, so the CLI has a home
of its own rather than riding inside the Code Mode MCP example. The
workers test opens /codemode on the Durable Object the way the
container would and drives the real runtime and dynamic worker.
examples/mcp goes back to what it was on main.

function errorJSON(error: unknown, status: number): Response {
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), {

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

5 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment on lines +230 to +232
ws.once("unexpected-response", (_request, response) => {
reject(new Error(`${url} answered HTTP ${response.statusCode} instead of upgrading`));
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Rejected handshakes retain open sockets

When the host refuses an upgrade, openSocket rejects without closing response. A keep-alive response can prevent the failed CLI from exiting.

Suggested change
ws.once("unexpected-response", (_request, response) => {
reject(new Error(`${url} answered HTTP ${response.statusCode} instead of upgrading`));
});
ws.once("unexpected-response", (_request, response) => {
response.destroy();
reject(new Error(`${url} answered HTTP ${response.statusCode} instead of upgrading`));
});
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

allow-pr Allow a PR to remain open.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants